Skip to content

SEP-1908: Drive ty's error-severity diagnostics to zero so make typecheck exits 0 - #1447

Merged
yyyyyyyan merged 32 commits into
mainfrom
SEP-1908
Sep 3, 2026
Merged

SEP-1908: Drive ty's error-severity diagnostics to zero so make typecheck exits 0#1447
yyyyyyyan merged 32 commits into
mainfrom
SEP-1908

Conversation

@yyyyyyyan

@yyyyyyyan yyyyyyyan commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Drives ty's error-severity diagnostics from 367 to 0 so make typecheck exits 0. Scope is error severity only: the ~3,200 warn diagnostics are unchanged by design, because the nine rules at warn mix first-party defects with dependency-typing artifacts.

Most of the diff is annotation-only. The parts that change runtime are listed below, each with what a caller observes.

What those 367 were

Classified from ty's own output on main, because "367 to 0" reads as a count-reduction chore while the distribution is the actual argument for doing it:

Class Count Nature
None/union safety — subscripting (59), iterating (14), operating on (25) or returning (41) a possibly-None value 139 Runtime-crash class
Possibly-unbound names 11 Runtime-crash class — the valkey payload defect below is one of them
Test-fixture generator annotations (-> TestClient rather than -> Generator[...]), every one under tests/ 50 No runtime risk
Liskov / override drift 21 Contract correctness
Runtime-computed models, where no static spelling exists 40 Irreducible

So roughly 41% of what the error tier was reporting sits in classes that surface as a 500, and it went unread because a permanently-red gate reports nothing — which is the case for driving the tier to zero rather than for zero as a number. The 19 suppressions this PR adds are the irreducible remainder, about 5%.

One caveat worth stating plainly: the most serious defect fixed here, delete_where truncating a table, is invisible to ty. _dml_where takes **equal_filters: Any, which absorbs the vestigial values=None, and a minimal reproduction of that shape checks clean. It was found by making the signature honest and writing a test for the adjacent guard, not by the checker. The audit and the gate are different sources of value, and only the second one is permanent.

Chokepoints — one signature, many consumers.

  • RemoteAPI's verb methods declare the whole union a JSON body may take (object, array, or None on a 204), so a caller reading the result as one shape was asserting something the transport never checked. as_json_object / as_json_array in app/core/requests/remote_api.py check it, generalizing the inline isinstance(response, Mapping) narrowing that already existed in app/sep/bundle_upload/plan.py. They are applied at 47 call sites in app/ (plus 10 in test helpers) that read the result as one shape; the call sites that discard the result or already branch on the union are untouched, including those in app/sep/bundle_upload/plan.py and app/sep/clients/pmm.py that handle the None themselves. Observable change: a mis-shaped upstream payload now raises HTTPBadGatewayException (502, with a detail) where it previously surfaced as a 500 or produced silently wrong data. This is the one user-visible change and carries a changelog fragment.
  • CasdoorSDK.request declared dict | list over a body that calls super().request(...), which returns None on a 204. The | None is restored and its callers narrow.
  • BaseSQLModelManager._exec declared an unparameterized TupleResult | ScalarResult | CursorResult, so every consumer's element type collapsed to Unknown | Row[Unknown]. Measured against the pinned sqlmodel 0.0.22: AsyncSession.exec declares exactly two overloads (SelectOfScalar -> ScalarResult, Select -> TupleResult) and no DML overload, so a DML statement infers Unknown there; execute() — which does return CursorResult — is @deprecated, and deprecated is an error rule. _exec takes those two overloads plus a DML arm stating what that path returns at runtime. update_where / delete_where gain overloads on returning, which always yields a list when truthy and a Result otherwise.
  • The nomad package's PEP 562 __getattr__ breaks a real import cycle and must declare object, so every name bound from it was typed object. Its two app/ consumers keep the lazy import for the runtime binding and take the class itself under TYPE_CHECKING; the runtime path is byte-for-byte unchanged and test_import_cycle.py's clean-interpreter probes still pass.
  • BaseExecutor.stream_logs / stream_file were annotated -> AsyncGenerator[...] over docstring-only bodies, which Python parses as plain coroutine functions. The tell that this was a real defect: app/tasks/routes.py and app/tasks/run_result.py both async for over the result without awaiting it, so the base was the half that was wrong. Both bodies are now async generators. stream_logs widens to TaskLog | None because NomadExecutor yields None for a step that holds an allocation without emitting lines, and the route renders it as an empty frame that keeps the response open.

Genuine defects the narrowing exposed.

  • get_async_session_maker_from_engine declared async_sessionmaker, imported async_sessionmaker, and called sessionmaker(class_=AsyncSession). The body now matches the contract; one test asserting the legacy class is corrected.
  • _TTLCache.get returned (hit, value) — a correlation no signature can express, and one a cached None would break on its own. It raises KeyError on a miss, the shape dict lookup already has.
  • RemoteAPI.session declared ClientSession over a value that is None before open() and after close(), which is exactly what app/tasks/run_result.py and its tests check it for.
  • Two __aexit__ overloads declared their three parameters non-optional; the protocol passes None on a clean exit.
  • URL.__get_pydantic_json_schema__ declared GetCoreSchemaHandler for what pydantic passes as a GetJsonSchemaHandler.
  • Three alters pre-check helpers were annotated non-optional over bodies that return None on failure — as their own docstrings said, and as every caller already guards for.
  • The valkey collection payload rendered a dashboard after two independent selection blocks, using names only those blocks bind. --sentinel defaults on so one always ran, but nothing local said so; both names are bound up front and the render is guarded on there being graphs.
  • Two ids read back from the database were returned as list[int] / dict[int, ...] while typed int | None. BaseSQLModel.id is nullable=False at the column and optional only before flush.
  • delete_where could truncate a table. delete_where forwarded a vestigial values=None that _dml_where has no parameter for, so it landed in **equal_filters and made the "at least one filter" guard — the manager layer's documented "no unbounded bulk ops" promise — structurally unreachable for every DELETE. delete_where(session) deleted every row. Pre-existing on main, found while writing a test for the returning guard below; every production and test call site already passes a filter, so dropping the argument only closes the hole. The guard now has tests on both arms.
  • The new returning overloads promise list[Any] for any non-bool returning, but _dml_where branches on truthiness, so returning=[] returned a CursorResult — an overload that lies, which is the failure mode the ticket's AC exists to prevent. An empty returning is rejected instead.

Response models pinned rather than filtered. Four routes derive their response model from the return annotation. Annotating the static base alone would have dropped owner, is_forbidden and is_deleted from the body and the published OpenAPI schema on GET /api/users/, /api/users/me and /api/users/{username}, and connectivity_warning from PUT /alters/{task_name}. Each now names its concrete model in an explicit response_model= and annotates the base. New tests assert the fields survive in both the body and the schema, and fail if a response_model= is dropped.

Two further routes take the same shape for a different reason. GET /api/inventory/nodes/{node_id}/system-observation and GET /api/inventory/services/{service_id}/system-observation were annotated with their response model over a handler returning the ORM row. Each now names the response model on the decorator and annotates the row type, so the row is serialized by the one serialize_response pass FastAPI already runs rather than round-tripped through a second model_validate. Both operations are byte-identical to the committed frontend/packages/api/specs/inventory.json.

Override drift. Eleven overrides were incompatible with their bases: contravariant parameter narrowing (the syncers, CasdoorUser.from_token_payload, PagerDutyEventsAlertProvider.send_alert), a dropped **extra_fields, a parameter renamed for ruff ARG003 (the base's parameters are positional-only now, so the name stops participating), and third-party bases that cannot move. PMMSyncer.perform_service_sync takes the same guard as the two MySQL overrides. node_id is a PMMService addition, not a field of the app/sep/inventory.py Service the widened signature names; the review caught this. Its test had been passing an app/inventory/models.py Service — a different class of the same name that does carry node_id — which is why the omission did not show. It now passes a PMMService, and a new test asserts the guard rejects a plain Service. BaseUser.get_users returns Sequence[Self] because list is invariant, which is why two overrides textually identical to the base were rejected.

send_alert also loses its @validate_call. With the parameter widened to the base Alert, the decorator coerced a mapping argument to Alert before the body converted it — and AlertSeverity has neither the lowercase values nor the name-or-value lookup that PagerDutyAlertSeverity accepts, so {"severity": "critical"} would have started raising ValidationError. The explicit model_validate in the body already validates; resolve_alert keeps its decorator.

Two shapes have no in-tree fix and take the mechanism the repository already has for that: a Group in scripts/classify_ty_diagnostics.py naming the discriminant, plus the per-site comments its report mode prescribes. Per-site rather than a file-wide override, because an override would also hide a genuinely broken future case in the same file.

They are registered as three Groups, not two: the runtime-computed-model shape is matched by two different ty messages, and only one of them carries a discriminant of its own. Function calls are not allowed in type expressions reads identically for the field-type factory the form DSL calls and for an ordinary call written into a type position by mistake, so it is a separate Group confined to the one module holding such a site — measured by stripping the invalid-type-form suppressions and re-running ty, which finds exactly one. Two negative classification tests pin it.

  • FieldExpr.__eq__ / __ne__ return a Predicate so F("field") == value builds a rule node, as SQLAlchemy does for a column. object.__eq__ is declared -> bool in typeshed. The comments they carried were mypy syntax and suppressed nothing under ty.
  • 17 annotations name a class chosen at runtime — the provider-selected user model, create_model-derived response models, form models the framework reads back through get_type_hints. Python has no spelling for "the class in this variable"; measured against ty 0.0.49, the only construction that avoids the diagnostic is list.__class_getitem__(model), which covers neither the annotation positions nor PaginatedResponse[...].

pyproject.toml is untouched: no rule severity changed and no [[tool.ty.overrides]] entry was added.

docs/development/ty-policy.md records the re-measured baseline and gains the change-policy trigger its list was missing — clearing diagnostics in bulk moves the recorded figure the same way a ty upgrade does.

Bundled fix

app/sep/apps/backup_mongo/**/pbm_*_payload (10 files) are regenerated by make regen-pbm-payloads because the shared credentials-path preamble they embed gained an isinstance(path, str) narrowing. No behaviour change; the generator's --check mode gates it.

Tested

Verified before opening:

command -v ty          # resolves inside the project virtualenv
ty --version           # ty 0.0.49
make typecheck         # exit 0
python3 scripts/classify_ty_diagnostics.py report

reports 0 error, 3201 warning, with every suppression in the tree claimed by a Group and none covering nothing.

Manual scenarios for QA:

  • List users as an admin (GET /api/users/), fetch /api/users/me and /api/users/{username}; confirm each response still carries the provider-specific fields (owner, isForbidden, isDeleted under Casdoor) and that /openapi.json still declares them on all three operations.
  • Create and then update an alters task group; confirm the update response body carries connectivity_warning.
  • Stream logs for a running Nomad-backed task through the task-history log view; confirm lines arrive and the stream terminates cleanly when the task finishes.
  • Download a task-history file and list a task history's files; confirm both still work.
  • Run an inventory sync against PMM and against a MySQL service; confirm both complete and the synced entities appear.
  • Run a connectivity check against a MySQL, a PostgreSQL and a MongoDB service; confirm each reports reachable, and that an auth-rejected server still reports reachable.
  • Run a MongoDB backup (PBM) task end to end; confirm it completes and the credentials file is resolved.
  • Trigger a PagerDuty alert; confirm the incident is created with the expected severity and payload.
  • Exercise a proxied Tasks API route that returns a list (periodic tasks, task history); confirm the page renders and pagination still works.

Checklist

  • New/modified functions have type hints and rST docstrings
  • New tests added for new features or bug fixes
  • Database migrations generated if models changed (make makemigrations) (N/A for this change — no model fields changed)
  • User-facing changes documented (README, inline help, UI text) (N/A for this change — the one observable change is covered by the changelog fragment)
  • Configuration changes documented with examples (N/A for this change — no configuration changed)

Review fixes

Five review comments and three findings from a self-review pass, all with tests:

  • _dml_where materializes a non-bool returning before inspecting it, so a one-shot iterable survives the emptiness check and the len() row read rather than passing the guard while empty and raising TypeError later.
  • tasks_api_detail narrows the history payload as well as the periodic one, so both answer with the documented 502 instead of one of them failing TaskDetailResponse validation as a 500.
  • list_task_history_files routes every non-None payload through the shape check, so an upstream array no longer reads as an empty object.
  • _build_query had parameterized builder on W while defaulting it to a concrete _QueryBuilder[Select[Any]], which ty reports as invalid-parameter-default — a diagnostic this branch introduced. Nothing solved W when builder was omitted, so the two list-path call sites got Unknown back and the _exec overloads could not key on the statement shape. Overloads split the two cases the way _exec, update_where and delete_where already do here.
  • from_bearer's :raises list now names the GrafanaException its new arm can raise, and says the arm is unreachable while _BEARER_TOKEN_TYPES is a non-empty literal.
  • docs/development/ty-policy.md records the count the tree actually reports, and the commit it was measured at.

Second review round

Six comments from a self-review pass, applied in bc75d54:

  • _dml_where spells the bool exclusion as isinstance, which narrows Iterable[str] | bool identically to the identity pair in one clause.
  • The two inventory system-observation GET routes pin their response model on the decorator, as described above.
  • Three prose blocks explaining why an annotation or signature is spelled the way it is are dropped — the Sequence variance note on BaseUser.get_users, the positional-only rationale on AliasableManagerMixin._identity_source, and the Liskov block above FieldExpr.__eq__, whose rationale scripts/classify_ty_diagnostics.py already carries as the group's own justification.

Merged main

main was merged in at 5f465e1. Two conflicts were substantive:

  • SEP-1923 dropped MySQL as a supported SEP database engine, taking DatabaseDialect.MYSQL and the _mutate_where_returning_with_for_update workaround with it. That deletion is kept; this branch's returning materialization guard rides on top, and the docstrings that described the dialect branch no longer do.
  • app/sep/bundle_upload/factory.py auto-merged into a wrong hybrid — main's widened Mapping parameter over this branch's dict | query body, which raises TypeError for any non-dict mapping. main's body is restored and the local annotated.

main also arrived carrying two error-severity diagnostics, since it had not been held to the zero-error bar: the | merge above, and a comprehension iterating an aioresponses mapping whose class-level default is None. Both are fixed, so make typecheck still exits 0.

…e a shape

RemoteAPI's verb methods declare the full JSON union an HTTP body may take --
an object, an array of objects, or None on a 204. Callers that read the result
as one concrete shape were asserting something the transport never checked.

Add as_json_object / as_json_array beside exception_for_status, generalizing
the inline isinstance(response, Mapping) narrowing already in
bundle_upload/plan.py, and apply them to the call sites that report. A
mis-shaped upstream payload now raises HTTPBadGatewayException instead of
surfacing as a TypeError further down. The verb signatures are unchanged and
the ~149 call sites that discard or already branch on the union are untouched.

Also restore the '| None' CasdoorSDK.request dropped -- super().request() can
still return None on a 204 -- and widen fetch_all_dict_items' callback
contract, whose own _coerce_dict_page already normalizes an envelope, a bare
list and None alike.
BaseSQLModelManager._exec declared an unparameterized
TupleResult | ScalarResult | CursorResult, so every consumer's element type
collapsed to 'Unknown | Row[Unknown]' and 21 manager methods across four
modules could not state what they return.

Measured against the pinned sqlmodel 0.0.22: AsyncSession.exec declares exactly
two overloads (SelectOfScalar -> ScalarResult, Select -> TupleResult) and no
DML overload, so a DML statement infers Unknown there. execute() -- which does
return CursorResult -- is @deprecated, and 'deprecated' is an error rule, so it
is not the way out either. _exec therefore takes three overloads: the two
sqlmodel declares, plus a DML arm stating what that path returns at runtime.

Also in the manager layer:

- _QueryBuilder becomes generic in the statement it builds, so _build_query
  returns a Select for the select path and an Update/Delete for the DML path
  rather than an unbound type variable.
- Whereable/Executable name Update and Delete instead of the DMLWhereBase
  mixin, which is not itself a statement type and so appeared to lack
  .options().
- The class-level type variables that no Generic bound (Model, ParentManager,
  AsTypeValidator.validate_class) carry their real types; AsTypeValidator
  becomes generic, which is contained to its eight in-file uses.
- *args: P.args without **kwargs: P.kwargs is not what ParamSpec means; the
  query builders take tuple[Any, ...].

Narrowing _exec revealed two ids read back from the database and returned as
list[int] / dict[int, ...] while typed int | None. BaseSQLModel.id is
nullable=False at the column and optional only before flush, so both read paths
now narrow explicitly.
The package's PEP 562 __getattr__ breaks a genuine import cycle and has to
declare a return of 'object', so every name bound from it was typed 'object'
and could not be used in an annotation.

The two app consumers keep the lazy import for the runtime binding and take the
class itself under TYPE_CHECKING, which is the only branch a checker reads. The
runtime path is byte-for-byte what it was, so the cycle stays broken -- the
clean-interpreter probes in test_import_cycle.py still pass. The two test
modules that only annotate with it import the class directly; they are leaves
and sit outside the cycle.

Fixing this in the package __init__ instead is not available: ruff counts
__all__ as a runtime use, so a TYPE_CHECKING import there trips TC004, and
spelling it as an explicit re-export trips PLC0414.
…verrides are

BaseExecutor.stream_logs and stream_file are annotated
'-> AsyncGenerator[...]' but their bodies are docstring-only, so Python parses
them as plain coroutine functions. The base therefore promised
'Coroutine[..., AsyncGenerator[...]]' while every override -- each containing a
yield -- is a real async-generator function, which is what produced the
override mismatches.

The tell that this is a real defect rather than a cosmetic one is the two
consumers: app/tasks/routes.py and app/tasks/run_result.py both 'async for'
over the result without awaiting it first, so the base was the half that was
wrong. Both bodies now raise NotImplementedError ahead of an unreachable yield,
matching the idiom the Celery override already uses.

stream_logs also widens to 'TaskLog | None'. NomadExecutor yields None for a
step that holds an allocation without emitting lines, and the route consumes it
deliberately -- 'log_line.model_dump_json() if log_line else ""' renders it as
an empty frame that keeps the response open. The None is part of the contract,
so the base now says so rather than the override contradicting it.
…lable()

BaseApp.periodic_task_schedules is already annotated correctly, so the policy
doc's 'fixable by annotating the attribute' does not apply here. Measured
instead: the discriminant is the SkipValidation wrapper. SkipValidation[X] is
Annotated[X, ...], and callable() narrowing through it drops the signature and
yields Top[(...) -> object]; the structurally identical but unwrapped
stop_on_short_page in app/core/pagination/models.py narrows through the same
ternary and reports nothing. Binding to a local first does not help -- only
avoiding callable() does, so the seed path and its test now test for the list
arm.

Two test doubles report the same rule for a different reason: the narrowed
attributes had no declared type. Annotating them fixes the wizard stub. The
FakePopen pair needs slightly more, because a callable is not provably distinct
from a tuple, so no narrowing test on that union yields a clean pair -- the arm
is settled at construction instead and communicate() just calls it.
…ey yield

72 fixtures across 42 files declared the type they yield, so ty read each as a
function returning that type and reported the generator it actually returns.
The annotation is now the generator, in the form each tree already prefers:
AsyncGenerator[X, None] for async (52 existing uses against 10 of the
one-argument form) and Iterator[X] for sync (76 against 3 of
Generator[X, None, None]).

Both AsyncGenerator arities type-check clean at python-version 3.11, so the
choice is consistency, not correctness. No fixture body changes.
Each site subscripts, iterates or operates on a value ty knows may be None. In
tests an explicit 'assert x is not None' is the idiomatic narrowing and
strengthens the test, so that is what these take -- never a silent widening.

Four sites needed something else:

- FakeTaskAPI.last_create_payload was 'dict | None = None' but no consumer ever
  checks for None; all sixteen subscript it directly, so the sentinel only ever
  produced a TypeError. It defaults to an empty dict.
- LOGGING_CONFIG is a dictConfig mapping inferred as a heterogeneous literal, so
  a nested lookup was not subscriptable. It carries the dict[str, Any] the
  settings field of the same name already declares.
- hasattr() does not narrow, so the validation-type test reads its args through
  typing.get_args instead.
- 'hasattr(route, "path") and ... in route.path' becomes
  'getattr(route, "path", "")', which is the same test in a form that narrows.
…astAPI reads it

The provider-selected user class and the derived alters response models are
computed at import time, so a checker cannot use them in an annotation. Where
nothing reads the annotation at runtime -- deps return types, Annotated
dependency aliases -- the annotation is now the static base, which is the
honest ceiling.

Where FastAPI *does* read it, the concrete class is pinned in an explicit
response_model= first. Four routes derive their response model from the return
annotation, so re-annotating them alone would have filtered owner,
is_forbidden and is_deleted out of both the JSON body and the published OpenAPI
schema on GET /api/users/{,me,{username}} and dropped connectivity_warning from
PUT /alters/{task_name}. New tests hold that line from both sides: the body and
the schema are each asserted against the fields the configured provider adds
over BaseUser, and both fail if a response_model= is dropped.

render_alters_create is safe to re-annotate: derive_cascade_create_route takes
its response_model explicitly and never infers one from the builder.

Also here, each an annotation that was simply untrue:

- BaseUser.get_users returned list[Self]; list is invariant, so two overrides
  textually identical to the base were rejected. Sequence[Self] is covariant
  and no caller mutates or index-assigns the result.
- Three alters pre-check helpers were annotated non-optional over bodies that
  return None on failure -- as their own docstrings said, and as every caller
  already guards for.
- get_async_session_maker_from_engine declared async_sessionmaker, imported
  async_sessionmaker, and called sessionmaker. The body now matches; one test
  asserting the legacy class is corrected to the documented contract.
- URL.__get_pydantic_json_schema__ declared GetCoreSchemaHandler for what
  pydantic passes as a GetJsonSchemaHandler.
- ClientRegistry.get returns T but reads a dict[..., BaseRemoteAPI]; the cache
  probe narrows with isinstance now, which the key already guaranteed.
- get_created_entity gains overloads restating ENTITY_MAPPING, so its four
  wrappers get their own model instead of the whole union.
Eleven overrides were incompatible with their bases, in four distinct ways.

Contravariant parameter narrowing -- the override accepted less than the base
promised. The syncers and CasdoorUser.from_token_payload now take the base type
and narrow inside with a guard that raises, which is what makes the inherited
contract true. PagerDutyAlertProvider.send_alert is the same shape, but its
@validate_call was doing the coercion, so the coercion is now explicit and the
base Alert the dispatcher actually passes is what it declares.

PMMSyncer.perform_service_sync gets no guard: it reads only node_id, which the
base Service carries, and a test already calls it with a base Service. A guard
there would have been a live behaviour change, not dead code.

Dropped parameters: BasePeriodicTaskManager.update omitted the base's
**extra_fields; it takes them and forwards them.

Renamed parameters: NodeManager._identity_source renamed session to _session for
ruff ARG003, which is an LSP break. The base's parameters are positional-only
now, so the name stops participating -- both call sites already pass
positionally, and this is the escape the standards prefer over a noqa.

Third-party bases that cannot move: settings_customise_sources narrowed two
sources below what pydantic-settings declares. The three overrides take the base
type and narrow through a structural Protocol naming the one capability the body
needs, env_vars. A nominal isinstance would have failed the existing mocks; the
Protocol accepts them and still rejects a source that carries no env_vars. The
two bytearray test doubles take typeshed's own find/rfind signature.
Eleven reads of a name that ty could not prove was bound. Classified before
fixing, since only a reachable one carries a test obligation -- none of the
eleven turned out to be reachable, and each is now bound locally rather than by
a correlation the reader has to reconstruct.

- Task.data predicates: parent_value was bound under a guard that is the
  disjunction of the two guards reading it, so no path reached an unbound read.
  It builds a SQL expression and does no I/O, so it is bound unconditionally.
- Advisor families: family_suffix was bound and read under the same 'if family'
  and now has a definite binding.
- RemoteAPI.request: response_data is read in the ClientResponseError handler,
  which is only reachable from raise_for_status() -- after the assignment --
  because the only ClientResponseError json() itself raises is ContentTypeError,
  caught by the earlier clause. It is initialized ahead of the try so that
  ordering stops being load-bearing.
- GrafanaUser.from_bearer raised last_error after a loop over a module constant.
  Empty is impossible today; the loop now reports that explicitly instead of
  raising NameError if the constant were ever emptied.
- The valkey payload's dashboard selection is spread over two independent
  blocks and rendered after both. --sentinel defaults on so one always binds,
  but nothing local says so; both names are bound up front and the render is
  guarded on there being graphs to render.
- The parametrized verb test's if/elif chain covers every parametrized method
  and now fails loudly on one that is added without a branch.
The long tail of per-site defects. Grouped by what was actually wrong:

Annotations that denied a nullability the code has. RemoteAPI.session declared
ClientSession over a value that is None before open() and after close() --
which is exactly what run_result.py and four tests check it for. The two
__aexit__ overloads declared their three parameters non-optional, but the
protocol passes None on a clean exit.

Annotations that over-claimed an element type. get_children_entities and
_schema_form_fields declared a narrower element than their sources yield;
can_sync_mapping is a heterogeneous dispatch table whose key is what makes a
lookup well-typed, and its docstring already said so.

Reads of a JSON payload as a concrete shape, wrapped in as_json_object like the
rest of the ticket.

Genuine narrowing gaps: getattr(self, name, None) does not narrow the
attribute; a Grafana account id read out of an untyped payload is checked as an
int now rather than merely not-None; a storage config keyed on an absent
storage_type would have produced a nonsense mapping.

_TTLCache.get returned (hit, value), a correlation no signature can express --
and one that a cached None would have broken on its own. It raises KeyError on
a miss, which is the shape dict lookup already has.

The two dipper payload mains return an exit code and now say so, and
coerce_target_list is annotated as the before-validator it is, matching
coerce_footer_template.
The remainder of the per-site work under app/ and sidecar/.

update_where and delete_where always materialize a list when 'returning' is
truthy -- both the RETURNING path and the MySQL FOR UPDATE workaround -- and a
Result otherwise, so overloads let their callers state which they asked for
instead of re-declaring the union and being wrong.

Four functions return a *type expression* assembled at runtime -- an
Annotated[...] form, a runtime StrEnum, an 'X | None' union -- none of which is
a 'type'. They say Any and name the reason, which is the honest ceiling; the
choice enum moves into a helper so its own name no longer has to match the
variable it lands in.

The MySQL syncer's schemas_index getter declared '| None' but substitutes an
empty iterator, so it never returns None.

check_mongodb caught pymongo.errors.OperationFailure while importing only the
package; importing the package does not bind the submodule. It imports both
now, and the fixture that injects a mock pymongo registers both entries in
sys.modules to match -- the same reason the real code needs the explicit
import.

Two routes returned a table model where a response model is declared and let
FastAPI convert; they build the declared model, so the annotation is true in
the source. The PBM payload preamble is regenerated for the credentials-path
narrowing (make regen-pbm-payloads).
…gnore

The tail of the test-side work.

_bind_suite assigned app_def on an instance, but it is a ClassVar -- the suite
documents itself as being subclassed with it bound, so the helper builds that
subclass instead.

_make_validation_error passed a 'msg' key to InitErrorDetails, which that
TypedDict does not carry; pydantic was ignoring it.

The masking test derived a model with type(name, (model,), ...), a class base
taken from a variable. pydantic's own create_model does the same thing and is
the idiom for a derived model.

The rest are the usual narrowing: a dependency callable that may be None, a
mock's await_args, a payload function extracted out of an exec namespace, a
manager's optional ordering. _build_syncer is generic in the syncer class it is
handed, so a caller naming StubTestSyncer gets one back.

test_manager reached AsyncSession.execute, which sqlmodel deprecates and the
policy treats as an error; it uses exec. The fetch_page stub drops a mypy-syntax
'type: ignore' that suppressed nothing under ty and annotates what it returns
now that fetch_all_dict_items accepts a raw page.
…e policy

Two diagnostic shapes have no fix available in this repository, so they take the
mechanism SEP-1906 built for exactly that: a Group in
classify_ty_diagnostics.py naming the discriminant, and the per-site comments
its report prescribes. Per-site rather than a [[tool.ty.overrides]] entry
because an override suppresses the rule for a whole file, and a genuinely broken
override written into one of these files later would never be reported.

predicate-dsl-comparison-operators -- FieldExpr.__eq__/__ne__ return a Predicate
so F(field) == value builds a rule node, as SQLAlchemy does for a column.
object.__eq__ is declared '-> bool' in typeshed and cannot move. The comments
they carried were mypy syntax and suppressed nothing under ty.

runtime-computed-model-in-type-position -- 17 annotations naming a class chosen
at runtime: the provider-selected user model, create_model-derived response
models, form models the framework reads back through get_type_hints. Python has
no spelling for 'the class in this variable'; measured against ty 0.0.49, the
only construction that avoids the diagnostic is list.__class_getitem__(model),
which covers neither the annotation positions nor PaginatedResponse[...], and
reads as a dodge rather than a fix.

ty-policy.md records the re-measured baseline -- 3,178 diagnostics, 0 error,
make typecheck exit 0 -- and gains the trigger its change-policy list was
missing: clearing diagnostics in bulk moves that figure the same way a ty
upgrade does. The narrowing helpers are registered in existing-patterns.md.
Appending the directive lengthened those lines past the formatter's limit, so
ruff-format wrapped them and the comment landed one line below the diagnostic --
unused where it sat, missing where it was needed. Inside the wrapped brackets
the placement is stable.
C19 wants the element type on an empty-collection assignment; C1 wants no local
annotation where inference suffices, so the query merge is one expression with
the same precedence the update() call had; C11 wants no new inline import, and
'import pymongo.errors' binds pymongo on its own, so the module import it
replaces is not needed.
The gates read the committed diff, so every docstring line this change touched
came back as newly added and had to meet the current conventions rather than
the ones its neighbours were written under.

Optional :type:/:rtype: directives are dropped from the lines this change
edited -- the annotation is the source of truth -- and the three alters
pre-check docstrings move from Google style to the rST the project uses. The
query builders and _exec document their parameters and returns. A spaced '--'
becomes an em dash.

Two deferrals, both using the marker the gate itself prescribes:

- The users listing has no upstream window to page against: both provider SDKs
  return the whole organization in one call.
- Thirteen fixtures duplicate a database bootstrap that predates this change,
  which only re-annotated their return types; promoting them is a cross-tree
  refactor. Same for the three query builders, which are already the thin
  wrappers the repeated-call-shape rule asks for.

The provider-field tests gained a positive control: the derived field set is
empty under Grafana, so the subset assertions would have passed vacuously
there -- the vacuous-assertion gate caught a real hole in them.
The narrowing helpers reject a mis-shaped upstream JSON body with
HTTPBadGatewayException, so a caller that previously saw a 500 (or silently
wrong data) now sees a 502 carrying a detail. Every other change in this ticket
is annotation-only and internal.

No deployment asymmetry and no new operator precondition: the change is in the
shared HTTP client helpers and reads no configuration.
Review of the typecheck-narrowing work found three places where the newly
declared contract was not the one the code honours.

- `update_where` / `delete_where` promise `list[Any]` for any non-bool
  `returning`, but `_dml_where` branches on truthiness, so `returning=[]`
  returned a `CursorResult`. Reject an empty `returning` instead, so the
  overloads state the truth.

- `delete_where` forwarded a vestigial `values=None` that `_dml_where` has no
  parameter for, so it landed in `**equal_filters` and made the "at least one
  filter" guard unreachable for every DELETE: `delete_where(session)` truncated
  the table. Pre-existing on main; every real caller already passes a filter,
  so dropping the argument only closes the hole.

- `send_alert` kept `@validate_call` after being widened to the base `Alert`,
  which coerced a mapping argument to `Alert` first and rejected the lowercase
  PagerDuty severities. Drop the decorator; the explicit conversion in the body
  already validates.

Also harden `download_task_history_file`, whose best-effort metadata lookup
called `.get` on an unnarrowed JSON body, and return the payload itself from
`as_json_object` / `as_json_array` rather than copying it on every call.

Claude-Session: https://claude.ai/code/session_01XBazM9VWrYcgSrJoQszTNT
…e a docstring

`check_list_pagination` scans one line above the first decorator, so a
three-line `pagination-ok:` block above `@router.get` put the pragma token out
of range. Move it inside the decorator, next to the `response_model` it
justifies.

Adding a `:raises:` to `_dml_where` turned its docstring into a structured one,
which `check_docstring_hygiene` then holds to full param and return coverage.
Document the rest.

Claude-Session: https://claude.ai/code/session_01XBazM9VWrYcgSrJoQszTNT
…count

The spec embeds route docstrings, and `list_users` lost the `:rtype:` line its
new `response_model=` makes redundant. The schema itself is byte-identical,
which is the confirmation that pinning the response model preserved the
published contract exactly.

The stale-group assertion wrote down a count equal to every registered group
but the one its fixture matches, so adding a group failed it for a reason the
test is not about. Derive it from `GROUPS` instead.

Claude-Session: https://claude.ai/code/session_01XBazM9VWrYcgSrJoQszTNT
Copilot AI balanced review requested due to automatic review settings September 1, 2026 21:36
@yyyyyyyan
yyyyyyyan requested review from a team, nachodd and peter-o-addo as code owners September 1, 2026 21:36
@yyyyyyyan yyyyyyyan added the qa in progress Someone is currently testing this PR - do not merge it label Sep 1, 2026
@github-actions github-actions Bot added app:mysql_backups PR touches the mysql_backups app slice app:report PR touches the report app slice app:snippets PR touches the snippets app slice app:tasks PR touches the tasks app slice app:topology PR touches the topology app slice svc:tasks PR touches the tasks service (app/tasks/) svc:inventory PR touches the inventory service (app/inventory/) large-diff Over 1500 changed lines, generated files discounted labels Sep 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Several widened contracts and JSON-shape paths remain inconsistent, and the DML iterable guard fails for valid generator inputs.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR comprehensively reduces ty error diagnostics through improved annotations, runtime payload validation, and targeted regression coverage. It also fixes several defects exposed during type narrowing.

Changes:

  • Corrects type contracts across API, database, executor, syncer, and test-fixture boundaries.
  • Adds upstream JSON-shape validation and preserves concrete response schemas.
  • Updates diagnostics policy, suppressions, generated payloads, tests, and changelog documentation.
File summaries
File Description
scripts/classify_ty_diagnostics.py Adds diagnostic classifications.
docs/development/ty-policy.md Updates the measured baseline.
changelog.d/SEP-1908.changed.md Documents new 502 behavior.
frontend/packages/api/specs/main.json Refreshes generated OpenAPI.
sidecar/grafana_service_account.py Validates Grafana account IDs.
app/api/deps.py Uses static base-user types.
app/api/routes/users.py Pins concrete response models.
app/core/alerts/providers/pagerduty.py Aligns provider override typing.
app/core/auth/models.py Widens user-list return contract.
app/core/auth/providers/casdoor/models.py Widens and validates token payloads.
app/core/auth/providers/grafana/models.py Makes bearer failure typing explicit.
app/core/auth/providers/grafana/sdk.py Validates Grafana response shapes.
app/core/celery/crud.py Forwards manager update fields.
app/core/db/crud.py Adds typed DML overloads and guards.
app/core/db/utils.py Corrects async session-maker typing.
app/core/pagination/models.py Types raw paginated payloads.
app/core/requests/__init__.py Exports JSON-shape helpers.
app/core/requests/registry.py Narrows cached client types.
app/core/requests/remote_api.py Defines JSON-shape validation helpers.
app/core/settings_override/registry.py Suppresses dynamic annotation diagnostic.
app/core/utils/cache.py Uses KeyError for cache misses.
app/core/utils/fields.py Corrects generic and schema-handler types.
app/inventory/crud.py Narrows persisted entity IDs.
app/inventory/routes/nodes.py Materializes node observation response.
app/inventory/routes/services.py Materializes service observation response.
app/sep/api/models.py Types raw inventory pages.
app/sep/api/routes/periodic_tasks.py Validates mutation responses.
app/sep/api/routes/task_history.py Validates stop responses.
app/sep/api/routes/task_stats.py Validates task-stat responses.
app/sep/apps/alters/api_routes.py Pins update response schema.
app/sep/apps/alters/deps.py Aligns response-builder typing.
app/sep/apps/alters/pre_checks.py Corrects optional return types.
app/sep/apps/atw/batch.py Validates history responses.
app/sep/apps/backup_mongo/deps.py Validates derived history payloads.
app/sep/apps/backup_mongo/restore/deps.py Validates restore history payloads.
app/sep/apps/backup_mongo/spec.py Guards missing storage type.
app/sep/apps/backup_mongo/pbm_creds_common.py Narrows credentials paths.
app/sep/apps/backup_mongo/pbm_config_payload Regenerates credentials handling.
app/sep/apps/backup_mongo/pbm_incremental_payload Regenerates credentials handling.
app/sep/apps/backup_mongo/pbm_logical_payload Regenerates credentials handling.
app/sep/apps/backup_mongo/pbm_physical_payload Regenerates credentials handling.
app/sep/apps/backup_mongo/pbm_status_payload Regenerates credentials handling.
app/sep/apps/backup_mongo/restore/pbm_force_resync_payload Regenerates credentials handling.
app/sep/apps/backup_mongo/restore/pbm_list_payload Regenerates credentials handling.
app/sep/apps/backup_mongo/restore/pbm_logical_restore_payload Regenerates credentials handling.
app/sep/apps/backup_mongo/restore/pbm_physical_restore_payload Regenerates credentials handling.
app/sep/apps/backup_mongo/restore/pbm_restore_config_payload Regenerates credentials handling.
app/sep/apps/backup_pg/deps.py Validates history responses.
app/sep/apps/checksums/models.py Corrects pre-validator typing.
app/sep/apps/dipper/payloads/pcs-collect-pmm-mysql.py Corrects main return type.
app/sep/apps/dipper/payloads/pcs-collect-pmm-valkey.py Guards dashboard rendering.
app/sep/apps/framework/api.py Types dynamic route models.
app/sep/apps/framework/apps.py Suppresses dynamic model diagnostics.
app/sep/apps/framework/form_dsl/conformance.py Uses complete field union.
app/sep/apps/framework/responses.py Validates task-list payloads.
app/sep/apps/framework/rules.py Updates DSL override suppressions.
app/sep/apps/framework/task_status.py Validates history envelopes.
app/sep/apps/mysql_backups/api_routes.py Materializes typed backup pages.
app/sep/apps/mysql_backups/forms.py Suppresses dynamic field diagnostic.
app/sep/apps/report/service.py Initializes advisor family suffix.
app/sep/apps/tasks/api_routes.py Validates task API payloads.
app/sep/apps/topology/api_routes.py Types and validates proxy payloads.
app/sep/bundle_upload/factory.py Simplifies query merging.
app/sep/clients/pmm.py Validates PMM response shapes.
app/sep/config.py Aligns settings-source overrides.
app/sep/db/seed.py Narrows schedule contribution types.
app/sep/routes/download_files.py Validates file metadata.
app/sep/routes/stream_logs.py Validates streamed status payloads.
app/sep/snippets/config.py Types runtime validation aliases.
app/sep/snippets/models/meta.py Builds runtime choice types.
app/sep/snippets/schema.py Removes redundant cast.
app/sep/sync/syncers/mysql/syncer.py Aligns sync override signatures.
app/sep/sync/syncers/pmm.py Widens PMM sync signature.
app/tasks/config.py Separates runtime/type imports.
app/tasks/connectivity/payload.py Narrows optional driver import.
app/tasks/crud.py Corrects result and expression typing.
app/tasks/execution/executors/nomad/models.py Narrows tracking access.
app/tasks/execution/models.py Corrects async-generator contracts.
app/tasks/execution/nomad_lifecycle.py Separates runtime/type imports.
app/tasks/models.py Returns typed duration mapping.
app/tasks/routes.py Safely narrows scheduled ETA.
tests/scripts/test_classify_ty_diagnostics.py Derives stale-group count.
tests/app/conftest.py Corrects fixture generator types.
tests/app/scan_recording.py Matches bytearray method signature.
tests/app/api/test_role_gate.py Narrows dependency callables.
tests/app/api/routes/test_oauth.py Suppresses dynamic user type.
tests/app/core/auth/test_config.py Aligns settings override signature.
tests/app/core/db/test_crud.py Covers DML guards and returns.
tests/app/core/db/test_list_query.py Corrects async fixture types.
tests/app/core/db/test_utils.py Corrects PostgreSQL fixture type.
tests/app/core/middleware/test_log_context.py Corrects client fixture type.
tests/app/core/requests/test_connectivity.py Corrects cache fixture type.
tests/app/core/requests/test_remote_api.py Covers JSON-shape validation.
tests/app/core/settings_override/conftest.py Corrects shared fixture types.
tests/app/core/settings_override/test_lifecycle.py Narrows proxy registry typing.
tests/app/core/settings_override/test_manager.py Uses typed session execution.
tests/app/core/settings_override/test_worker.py Corrects generator fixture types.
tests/app/core/test_pagination.py Models malformed page input.
tests/app/core/test_requests.py Makes parametrization exhaustive.
tests/app/core/utils/test_openapi.py Types dynamic OpenAPI models.
tests/app/inventory/conftest.py Corrects fixture generator types.
tests/app/inventory/routes/test_identity_links.py Narrows persisted node IDs.
tests/app/inventory/test_crud.py Narrows persisted node ID.
tests/app/inventory/test_role_gate.py Corrects client fixture type.
tests/app/sep/api/routes/test_app_info.py Corrects client fixture type.
tests/app/sep/api/routes/test_connectivity_check.py Corrects dependency fixture types.
tests/app/sep/api/routes/test_dashboard.py Corrects dependency fixture types.
tests/app/sep/api/routes/test_hosts.py Corrects client fixture type.
tests/app/sep/api/routes/test_task_history.py Corrects client fixture type.
tests/app/sep/api/routes/test_task_stats.py Corrects client fixture type.
tests/app/sep/api/test_router.py Narrows route path access.
tests/app/sep/apps/alert_troubleshooting/conftest.py Corrects client fixture type.
tests/app/sep/apps/alerts/conftest.py Corrects session fixture type.
tests/app/sep/apps/alerts/test_loader.py Corrects cache fixture type.
tests/app/sep/apps/alters/conftest.py Corrects guard fixture types.
tests/app/sep/apps/atw/conftest.py Corrects async fixture types.
tests/app/sep/apps/atw/test_send.py Corrects session fixture type.
tests/app/sep/apps/backup_mongo/pbm_payload_exec.py Normalizes callable test results.
tests/app/sep/apps/backup_mongo/test_pbm_compression_flags.py Narrows extracted callables.
tests/app/sep/apps/backup_pg/test_contract.py Narrows optional request body.
tests/app/sep/apps/conftest.py Corrects guard fixture types.
tests/app/sep/apps/dipper/conftest.py Corrects API fixture type.
tests/app/sep/apps/framework/contract_suite.py Narrows optional request body.
tests/app/sep/apps/framework/kit.py Validates synthetic API payloads.
tests/app/sep/apps/framework/test_contract_suite.py Types dynamic contract models.
tests/app/sep/apps/framework/test_registry.py Corrects cache fixture type.
tests/app/sep/apps/framework/test_scaffold.py Narrows wizard callbacks.
tests/app/sep/apps/inventory/test_api_routes.py Corrects API fixture type.
tests/app/sep/apps/mysql_backups/payload_harness.py Validates extracted functions.
tests/app/sep/bundle_upload/test_resolver.py Narrows optional reason.
tests/app/sep/db/test_seed.py Narrows schedule and fixture types.
tests/app/sep/routes/test_shared_route_auth.py Corrects client fixture type.
tests/app/sep/snippets/models/test_meta.py Uses supported union introspection.
tests/app/sep/snippets/test_crud.py Narrows optional ordering.
tests/app/sep/snippets/test_haproxy_snippets.py Narrows optional choices.
tests/app/sep/snippets/test_masking.py Uses typed dynamic model creation.
tests/app/sep/sync/conftest.py Corrects session fixture type.
tests/app/sep/sync/syncers/system_facts/test_payload.py Narrows optional package results.
tests/app/sep/sync/syncers/test_pmm.py Updates validation-error fixture.
tests/app/sep/sync/test_models.py Preserves concrete syncer type.
tests/app/sep/test_main.py Corrects guarded client type.
tests/app/sep/test_proxy_routes_with_override.py Corrects session-maker fixture.
tests/app/sep/test_settings_override_integration.py Corrects session-maker fixture.
tests/app/sep/test_settings_override_worker.py Corrects worker fixture types.
tests/app/tasks/conftest.py Corrects shared fixture types.
tests/app/tasks/connectivity/test_routes.py Corrects client fixture type.
tests/app/tasks/db/test_engine.py Verifies async session maker.
tests/app/tasks/logs/test_line_split.py Matches bytearray override signature.
tests/app/tasks/periodic/conftest.py Corrects periodic fixture types.
tests/app/tasks/periodic/test_models.py Narrows optional next-run time.
tests/app/tasks/test_celery.py Narrows mock call arguments.
tests/app/tasks/test_celery_settings_override.py Corrects session-maker fixture.
tests/app/tasks/test_crud.py Narrows persisted IDs.
tests/app/tasks/test_deps.py Narrows optional metadata.
tests/app/tasks/test_request_executor_http.py Corrects imports and fixture type.
tests/app/tasks/test_role_gate.py Corrects client fixture type.
tests/app/tasks/test_run_result.py Imports concrete Nomad model.
tests/app/tasks/test_settings_override_integration.py Corrects session-maker fixture.
Review details
  • Files reviewed: 167/167 changed files
  • Comments generated: 5
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread app/core/db/crud.py Outdated
Comment thread app/sep/apps/tasks/api_routes.py
Comment thread app/sep/routes/download_files.py
Comment thread app/sep/sync/syncers/pmm.py
Comment thread scripts/classify_ty_diagnostics.py Outdated
Address the review comments on #1447.

- `_dml_where` materializes a non-bool `returning` before inspecting it, so a
  one-shot iterable survives the emptiness check, the MySQL `set()` read and
  the `len()` row read instead of passing the guard while empty and raising
  `TypeError` later.
- `PMMSyncer.perform_service_sync` guards the carrier it was widened to accept.
  `node_id` is a `PMMService` addition; the base `Service` the signature now
  names does not carry it, so the two sibling overrides already guard the same
  way. Its test passed an `app.inventory.models.Service`, a different class of
  the same name that happens to have `node_id`, and now passes a `PMMService`.
- `tasks_api_detail` narrows the history payload as well as the periodic one,
  so a mis-shaped upstream answer raises the documented 502 rather than
  failing `TaskDetailResponse` validation with a 500.
- `list_task_history_files` routes every non-`None` payload through the shape
  check, so an upstream array no longer reads as an empty object.
- Split the call-in-type-expression alternative out of
  `runtime-computed-model-in-type-position`, whose message carries its own
  discriminant, into a group confined to the one module holding such a site.
  The message alone reads the same for a genuine mistake, so left unconfined it
  would authorize suppressing one.

Claude-Session: https://claude.ai/code/session_01R91BB414V223kzWimUqENs
…h from_bearer grew

Two findings from the review pass over this branch.

- `_build_query` parameterized `builder` on `W` while defaulting it to a
  concrete `_QueryBuilder[Select[Any]]`, which `ty` reports as
  `invalid-parameter-default` — a diagnostic this branch introduced, since the
  parameter was unparameterized before. Nothing solves `W` when `builder` is
  omitted, so the two list-path call sites got `Unknown` back and the `_exec`
  overloads added alongside could not key on the statement shape, which is what
  they exist for. Overloads split the two cases the way `_exec`, `update_where`
  and `delete_where` already do in this file. The three sibling signatures that
  still spelled `_QueryBuilder` bare become explicit.
- `from_bearer`'s new `if last_error is None` arm can raise `GrafanaException`,
  which its `:raises` list did not mention. The arm is unreachable while
  `_BEARER_TOKEN_TYPES` is a non-empty literal, so the docstring says that
  rather than a test asserting a state the module cannot reach.

Claude-Session: https://claude.ai/code/session_01R91BB414V223kzWimUqENs
…ommit

The recorded figure is a live claim about the current configuration, so it has
to name the tree it was taken from. It read 3,178 against a tree reporting
3,175 before the review fixes and 3,169 after — the drift the change-policy
entry added just below it exists to catch. Naming the commit is what lets the
next reader tell a stale figure from a current one.

Claude-Session: https://claude.ai/code/session_01R91BB414V223kzWimUqENs
Comment thread app/core/db/crud.py Outdated
Comment thread app/inventory/routes/nodes.py
Comment thread app/inventory/routes/services.py Outdated
Comment thread app/core/auth/models.py Outdated
Comment thread app/inventory/crud.py Outdated
Comment thread app/sep/apps/framework/rules.py Outdated
…ls, isinstance guard, comment trims

- `_dml_where` spells the `bool` exclusion as `isinstance`, which narrows
  `Iterable[str] | bool` identically to the identity pair in one clause.
- The two inventory system-observation GET routes pin their response model on
  the decorator and annotate the row type, dropping a `model_validate` pass
  that duplicated the one `serialize_response` already runs. The published
  schema is byte-identical to the committed spec for both operations.
- Three prose blocks explaining why an annotation or signature is spelled the
  way it is are dropped: they are mechanism a maintainer reads off the
  signature and the gates, not contract, and one restated a rationale
  `scripts/classify_ty_diagnostics.py` already carries.
Five files conflicted, plus one silent auto-merge hybrid.

- `app/core/db/crud.py`: main dropped MySQL as a supported engine, taking
  `DatabaseDialect.MYSQL` and the `_mutate_where_returning_with_for_update`
  workaround with it. Took main's deletion and kept this branch's
  `returning` materialization guard; the docstrings that described the
  dialect branch no longer do.
- `app/core/requests/remote_api.py`: both sides added module-level helpers at
  the same point. Union — `as_json_object`/`as_json_array` and
  `is_non_json_success` all survive, and `__all__` names all three.
- `app/core/utils/fields.py`, `tests/app/core/requests/test_remote_api.py`:
  took main's enum rename and the union of both import lists.
- `tests/app/conftest.py`: took main's removal of the real-MySQL fixtures.
- `app/sep/bundle_upload/factory.py` auto-merged into a wrong hybrid —
  main's widened `Mapping` parameter over this branch's `dict | query` body,
  which raises `TypeError` for any non-dict mapping. Restored main's body and
  annotated the local so the merged value type is the declared one.

Two error-severity diagnostics arrived with main, which had not been held to
the zero-error bar: the `|` merge above, and a comprehension iterating an
`aioresponses` mapping whose class-level default is `None`. The latter now
iterates `.items()` like the other fourteen sites in that file, and counts one
entry per recorded request rather than per distinct key.
Merging main added 32 warning-severity diagnostics, which moves the recorded
figure the same way clearing diagnostics in bulk does. The error count stays at
zero: the two errors that arrived with main were fixed in the merge commit.
@yyyyyyyan yyyyyyyan added qa not required Merge without a QA sign-off: substitutes for 'qa passed' in label-gate. Does not skip any test job. and removed qa in progress Someone is currently testing this PR - do not merge it labels Sep 3, 2026
@yyyyyyyan

yyyyyyyan commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

One finding from re-measuring the get_created_entity overloads, out of scope for this PR and worth its own ticket.

The overload set masks three argument-type violations. At app/sep/apps/mysql_backups/restore/deps.py:77, :90 and :108, form.service_id / form.schema_id are str | None, passed to entity_id, which every overload and the implementation declare as int. Each site guards with .isdigit() and then passes the string rather than int(...).

With the overload set in place ty reports nothing at those lines. Removing the overloads — or replacing them with cast in the four wrappers — makes it report Expected int, found str | None at all three. It happens to work at runtime because the value is interpolated into a URL path, so this is a silent contract violation rather than a live crash.

restore/deps.py is not in this PR's diff, so this is follow-up work, not a change to make here.

Measured alongside it, for the record on whether the overloads earn their place — three states, ty against this branch:

Variant Lines error-severity Total diagnostics
Overloads (shipped) ~45 0 3201
Five cast calls instead ~18 0 3207
Neither 0 4 3217

All five arms are load-bearing: dropping only the general SyncInventoryEntityTypeEnum arm breaks framework/spec.py:252 and :256, which pass a variable entity type. The four concrete arms resolve .schema_id in checksums/spec.py and .address in mysql_backups/restore/deps.py, which is real narrowing value the error tier alone does not show — both are warning severity, and this branch gates on errors only.

Pass int(...) where the restore resolver hands a digit-string to get_created_entity. app/sep/apps/mysql_backups/restore/deps.py lines 77, 90 and 108 pass form.service_id / form.schema_id — both str | None — to a parameter every signature declares int, after guarding with .isdigit() but never converting. Interpolation into the URL path hides it at runtime and the overload set hides it from the type checker. Out of scope for SEP-1908, which does not touch that file.

@yyyyyyyan

Copy link
Copy Markdown
Contributor Author

@copilot resolve the merge conflicts in this pull request

Copilot AI and others added 2 commits September 3, 2026 19:25
# Conflicts:
#	tests/app/sep/apps/atw/conftest.py

Co-authored-by: yyyyyyyan <24644216+yyyyyyyan@users.noreply.github.com>
Co-authored-by: yyyyyyyan <24644216+yyyyyyyan@users.noreply.github.com>

Copilot AI commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Resolved and published in merge commit e9fe2748; follow-up compatibility fixes for the strict type-check gate are in e6234d76. ty exits 0, and the affected test suites pass.

@yyyyyyyan
yyyyyyyan merged commit a157c14 into main Sep 3, 2026
21 checks passed
@yyyyyyyan
yyyyyyyan deleted the SEP-1908 branch September 3, 2026 19:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app:alert_troubleshooting PR touches the alert_troubleshooting app slice app:alerts PR touches the alerts app slice app:alters PR touches the alters app slice app:atw PR touches the atw app slice app:backup_mongo PR touches the backup_mongo app slice app:backup_pg PR touches the backup_pg app slice app:checksums PR touches the checksums app slice app:dipper PR touches the dipper app slice app:inventory PR touches the inventory app slice app:mysql_backups PR touches the mysql_backups app slice app:report PR touches the report app slice app:snippets PR touches the snippets app slice app:tasks PR touches the tasks app slice app:topology PR touches the topology app slice frontend large-diff Over 1500 changed lines, generated files discounted python qa not required Merge without a QA sign-off: substitutes for 'qa passed' in label-gate. Does not skip any test job. svc:inventory PR touches the inventory service (app/inventory/) svc:tasks PR touches the tasks service (app/tasks/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants